前言

尼玛一直觉得自定义View好难,但是呢自定义View在Android开发过程中又是无法逃避的坎,一个App我觉得除了数据外,就属外观了,漂亮的界面总是让人心旷神怡。每每看到别人做的酷炫的控件都好羡慕,想知道他们是怎么实现的,相信很多初学者都有和我一样的苦恼。但是呢,学习是一个不断积累的过程,不能指望着一步登天,所以还是需要先打好基础,一步一步来,下面让我们跟着大神一步步学习自定义View吧(ps:我不是大神==,我也只是个菜鸟,我说的大神是洋神, 我也只是跟着他的步骤将他博客里的案例做一下,顺便做些自己的笔记)。

今天是第一课,是阅读洋神Android 自定义View (一) 文章做的笔记。

自定义View的步骤

一般来说,自定义View需要有以下四个步骤,视实际的情况某些步骤可有可无:

  • 自定义View的属性
  • 在View的构造方法中获得自定义的属性
  • 重写onMeasure
  • 重写onDraw

自定义View的属性

通常来说,我们在做自定义View的时候,都需要自定义View的属性,比如字体的大小、字体的颜色等等属性,我们希望可以像Android内置的控件一样可以在XML文件中配置这些属性,这时候就需要自定义View的属性,自定义View的属性需要在工程目录下的res/values子目录下建立一个attr.xml文件,在里面定义我们需要的属性,比如:

1
2
3
4
5
6
7
8
9
10
11
12
<?xml version="1.0" encoding="utf-8"?>
<resources>
<attr name="titleText" format="string"></attr>
<attr name="titleTextColor" format="color"></attr>
<attr name="titleTextSize" format="dimension"></attr>
<declare-styleable name="CustomeTitleTextView">
<attr name="titleText"/>
<attr name="titleTextColor"/>
<attr name="titleTextSize"/>
</declare-styleable>
</resources>

在这里我们定义了三个属性,分时是titleTexttitleTextColor以及titleTextSize,其实还有另外一种写法如下,两者的效果是一样的:

1
2
3
4
5
6
7
8
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CustomeTitleTextView">
<attr name="titleText" format="string"/>
<attr name="titleTextColor" format="color"/>
<attr name="titleTextSize" format="dimension"/>
</declare-styleable>
</resources>

相信大家也看到了,我们在声明属性时有一个format属性,format其实是该属性的取值类型,一共有string、color、dimension、integer、enum、reference、float、boolean、fraction、flag:

  • string:取值类型为字符串

  • color:取值类型为颜色值,比如app:color="#ffffff"

  • dimension:取值类型为尺寸值,比如app:dimension="30dp"

  • integer:取值类型为整数,比如app:count="12"

  • enum:取值类型为枚举,比如

    1
    2
    3
    4
    5
    6
    <declare-styleable name="名称">
    <attr name="属性名称">
    <enum name="horizontal" value="0"/>
    <enum name="vertical" value="1"/>
    </attr>
    </declare-styleable>

    那么在xml文件中就可以这样使用:

    1
    app:属性名称="horizontal"
  • reference:取值类型为某一资源ID,比如app:background="@drawable/picture"

  • float:取值类型为浮点数,比如app:fromAlpha="0.1"

  • boolean:取值类型为布尔值,比如app:focusable=true

  • fraction:取值类型为百分数,比如app:pivotX="200%"

  • flag:取值类型为位或运算,比如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <declare-styleable name="名称">
    <attr name="windowSoftInputMode">
    <flag name = "stateUnspecified" value = "0" />
    <flag name = "stateUnchanged" value = "1" />
    <flag name = "stateHidden" value = "2" />
    <flag name = "stateAlwaysHidden" value = "3" />
    <flag name = "stateVisible" value = "4" />
    <flag name = "stateAlwaysVisible" value = "5" />
    <flag name = "adjustUnspecified" value = "0x00" />
    <flag name = "adjustResize" value = "0x10" />
    <flag name = "adjustPan" value = "0x20" />
    <flag name = "adjustNothing" value = "0x30" />
    </attr>
    </declare-styleable>

    那么就可以这样使用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    <activity
    android:name = ".StyleAndThemeActivity"
    android:label = "@string/app_name"
    android:windowSoftInputMode = "stateUnspecified | stateUnchanged | stateHidden">
    <intent-filter>
    <action android:name = "android.intent.action.MAIN" />
    <category android:name = "android.intent.category.LAUNCHER" />
    </intent-filter>
    </activity>

接下来我们就可以在布局文件中声明我们的自定义View:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.glemontree.customview01.MainActivity">
<com.glemontree.customview01.CustomView
android:layout_width="200dp"
android:layout_height="100dp"
app:titleText="3712"
app:titleTextSize="40sp"
app:titleTextColor="#ff0000"/>
</RelativeLayout>

你会发现,在使用自定义属性的时候有个前缀app,其实这个不是必须得是app,但是必须得和布局文件开始处xmlns:app="http://schemas.android.com/apk/res-auto"中的xmlns后面的名称相同。

在View的构造方法中获得自定义的属性

接下来,我们就需要在我们自定义View的构造方法中获得自定义属性,代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public CustomView(Context context) {
this(context, null);
}
public CustomView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CustomView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomeTitleTextView, defStyle, 0);
int n = a.getIndexCount();
for (int i = 0; i < n; i++)
{
int attr = a.getIndex(i);
switch (attr)
{
case R.styleable.CustomeTitleTextView_titleText:
mTitleText = a.getString(attr);
break;
case R.styleable.CustomeTitleTextView_titleTextColor:
// 默认颜色设置为黑色
mTitleTextColor = a.getColor(attr, Color.BLACK);
break;
case R.styleable.CustomeTitleTextView_titleTextSize:
// 默认设置为16sp,TypeValue也可以把sp转化为px
mTitleTextSize = a.getDimensionPixelSize(attr, (int) TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()));
break;
}
}
a.recycle();
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setTextSize(mTitleTextSize);
mBound = new Rect();
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);

首先呢,我们发现我们在自定义View时实现了三个构造方法,那么这三个构造方法有什么区别呢(参考自文章android View的三个构造方法 简单总结)?

其实,第一个构造方法是提供给我们在代码中生成控件使用的,比如:

1
2
3
4
5
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(new CustomView(this));
}

在上面的代码中,我们直接通过new CustomView(this)生成了控件,没有涉及到属性的添加,不能在布局文件中使用,如果要在布局文件中使用,必须添加第二个构造方法。

第二个方法是在XML布局文件中插入控件使用的,其中attrs参数就是我们在XML文件中自定义控件的属性,其实第二个构造函数也是调用第三个构造函数,这里我在第三个参数传入R.attr.customViewStyle

第三个方法的第三个参数defStyleAttr的意义是从APP或者Activity的Theme中设置的该控件的属性的默认值,比如我在attr.xml文件中定义了自定义属性:

1
<attr name="customViewStyle" format="reference"></attr>

然后我们在App或者Activity的Theme中设置它的值:

1
2
3
4
<style name="AppTheme" parent="AppBaseTheme">
<!-- All customizations that are NOT specific to a particular API-level can go here. -->
<item name="customViewStyle">@style/custom_view_style</item>
</style>

其中的custom_view_style:

1
2
3
<style name="custom_view_style">
<item name="circleWidth">8dp</item>
</style>

这样当你的XML文件中没有给该控件的circleWidth定义值的时候,默认值就是8dp。需要注意的是:

1
TypedArray array=context.obtainStyledAttributes(set, attrs, defStyleAttr, defStyleRes);

该方法的第四个参数defStyleRes,可以直接传入自定义的style,如果defStyleAttr为0,defStyleRes才会起作用。
android控件获取属性值的优先顺序:

  • 在XML文件中直接定义;
  • 在XML文件引用的style;
  • 就是从如上所说的defStyleAttr中取值;
  • 从defStyleRes取值;
  • 从Activity或者Application的Theme中取值;

在第三个构造方法中获取自定义属性的取值时还有另外一种方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public CustomView(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CustomeTitleTextView, defStyle, 0);
mTitleText = a.getString(R.styleable.CustomeTitleTextView_titleText);
mTitleTextSize = a.getDimensionPixelSize(R.styleable.CustomeTitleTextView_titleTextSize,
(int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 16, getResources().getDisplayMetrics()));
mTitleTextColor = a.getColor(R.styleable.CustomeTitleTextView_titleTextColor, Color.BLACK);
a.recycle();
mPaint = new Paint();
mPaint.setAntiAlias(true);
mPaint.setTextSize(mTitleTextSize);
mBound = new Rect();
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
}

我个人更加倾向于后面这种方法,这里我对构造方法中的一些函数进行介绍:

  • TypedVlaue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 16, getResources.getDisplayMetrics())

    该函数的原型为TypedValue.applyDimension(int unit, float value, DisplayMetrics metrics),用来进行单位的转换,其中第一个参数是第二个参数的单位,并将该单位的值转换为px(像素),DisplayMetrics是一个获取屏幕信息的类,density是设备密度,dp = px / density,因此该方法就是一个将各种单位的值转换为像素的方法。

  • getDimensionPixelSize是获取某个dimen的值,如果单位是dpsp,则需要将其乘以density,终归来说,这个函数的返回值得到的是像素值。

onMeasure方法这里我们没有进行重写,在onMeasure方法中直接调用父类的onMeasure方法,因此这里暂时不加以介绍。下面我们看看onDraw方法!

重写onDraw

onDraw方法顾名思义就是进行绘图的方法,这里我们在onDraw方法中绘制了一个矩形和一行文字,如下:

1
2
3
4
5
6
7
8
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(Color.YELLOW);
canvas.drawRect(0, 0, getMeasuredWidth(), getMeasuredHeight(), mPaint);
mPaint.setColor(mTitleTextColor);
canvas.drawText(mTitleText, getWidth() / 2 - mBound.width() / 2, getHeight() / 2 + mBound.height() / 2, mPaint);
}

这里其实还有个坑,就是drawText()方法,大家有兴趣的可以去研究一下。

现在基本上已经有效果了,运行程序,结果如下:

按照洋神的思路(/捂脸),改下布局文件中CustomView的layout_width和layout_height属性,将CustomView的layout_width和layout_height属性均改为wrap_content,此时运行程序,结果如下:

这里因为我们没有重写onMeasure方法,而是直接调用的父类的onMeasure方法,当我们在布局文件中明确设置CustomView的宽度和高度时,系统帮我们测量的结果就是我们设置的值,但是但我们设置成wrap_content或者match_parent时,系统帮我们测量的结果就是match_parent的值,因此,一般我们都需要重写onMeasure方法,并且在onMeasure方法中根据实际的情况进行不同的设定。

重写onMeasure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int width = 0, height = 0;
if (width == MeasureSpec.EXACTLY) {
width = widthSize;
} else {
mPaint.setTextSize(mTitleTextSize);
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
width = getPaddingLeft() + getPaddingRight() + mBound.width();
}
if (height == MeasureSpec.EXACTLY) {
height = heightSize;
} else {
mPaint.setTextSize(mTitleTextSize);
mPaint.getTextBounds(mTitleText, 0, mTitleText.length(), mBound);
height = getPaddingTop() + getPaddingBottom() + mBound.height();
}
setMeasuredDimension(width, height);
}

并且修改布局文件,给CustomView添加上padding属性以及layout_centerInParent属性,运行结果如下:

总结

本文是Android自定义View修炼之路的开篇,虽然大量参考了洋神的博客,但是在自己动手操作的过程中对其中自定义View的过程有了更加深刻的了解,收获还是很大的,感谢洋神的分享!